Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add EuiDelayHide component #412

Merged
merged 7 commits into from
Feb 16, 2018

Conversation

sorenlouv
Copy link
Member

@sorenlouv sorenlouv commented Feb 15, 2018

Closes #382

This component will conditionally show/hide a child component. It will ensure that the child is visible for at least 1000ms (default). This avoids UI glitches with loading spinners and other elements that are rendered conditionally.

<EuiDelayHide hide={!isLoading} render={() => <div>Loading...</div>} />

EuiDelayHide uses a render props to render the child component.

TODO:

  • Add EuiDelayHide component
  • Add tests
  • Update changelog
  • Update documentation

@sorenlouv sorenlouv requested a review from cjcenizal February 15, 2018 16:47
@sorenlouv
Copy link
Member Author

@cjcenizal I was not sure where this component belonged, so added it to src/components. Let me know if it fits better somewhere else.

Copy link
Contributor

@cjcenizal cjcenizal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👏 This is awesome man! I had a few minor suggestions about the docs and some questions about the desired behavior.

import { EuiFlexItem } from '../../../../src/components/flex/flex_item';
import { EuiCheckbox } from '../../../../src/components/form/checkbox/checkbox';
import { EuiFormRow } from '../../../../src/components/form/form_row/form_row';
import { EuiFieldNumber } from '../../../../src/components/form/field_number';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we import all of these from the main components module? I have a regex which will replace that with @elastic/eui in the code example.

import {
  EuiDelayHide,
  EuiFlexItem,
  EuiCheckbox,
  EuiFormRow,
  EuiFieldNumber,
} from '../../../../src/components';

Copy link
Member Author

@sorenlouv sorenlouv Feb 15, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure I'll do that. But it seems to give some problems to vscode (and probably static analysis tools in general). Eg. I can "go to definition" when I use
import { EuiCheckbox } from '../../../../src/components/form/checkbox/checkbox';, but not with
import { EuiDelayHide} from '../../../../src/components';.

This makes it a bit harder to navigate the codebase. Also: automatic imports will import using the first.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hear you. Though I'm not sure we should optimize the code for any particular editor... I use SublimeText myself so I don't get the benefits vscode is giving you. In the absence of a standard editor, I think it's better to optimize for no editor, i.e. make the code as readable as possible when viewed as regular text.


render() {
return (
<div>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we import React, { Fragment } from 'react'; at the top of the file and replace this div with a <Fragment>?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

],
text: (
<p>
<EuiCode>DelayHide</EuiCode> is a component for conditionally toggling
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we change this to <EuiCode>EuiDelayHide</EuiCode> to use the full component name?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

};

this.lastRenderedTime = this.props.hide ? 0 : Date.now();
this.shouldRender = false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe instead of setting these variables here, we should extract the logic in componentWillReceiveProps into an instance method and invoke it either here in the constructor or in componentWillMount? I think this is a corner case, but it seems the most "correct".

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with that. I'm not 100% sure how you want it to be - can you show it with code? :)

clearTimeout(this.timeout);

const visibleDuration = Date.now() - this.lastRenderedTime;
const timeRemaining = minimumDuration - visibleDuration;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to resume the countdown instead of resetting it entirely? In other words, why not debounce the hiding action?

Copy link
Member Author

@sorenlouv sorenlouv Feb 16, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand you correctly, I think the current implementation is correct.

In our case there might be several loading events with a few ms between them. If each takes 500ms, and they have 10ms between them, we should show the loading spinner for 1520ms (500ms + 10ms + 500ms + 10ms + 500ms) and then hide it immediately after the last loading event finishes.

If we reset the timer between each event the loading spinner will be displayed for 2020ms (500ms + 10ms + 500ms + 10 + 1000ms).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, you know what, you're right. I completely forgot that the whole point of this component is to show the waiting feedback for a bare minimum amount of time.

};

shouldComponentUpdate() {
return this.shouldRender;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to use shouldComponentUpdate here? What's it doing for us?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No - will remove it again.
I was of the impression that it would avoid re-rendering the children but it happens anyway so ¯_(ツ)_/¯

describe('when EuiDelayHide is visible initially and is changed to hidden', () => {
let wrapper;
beforeEach(() => {
jest.useFakeTimers();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't know this existed! I'm going to use this to shave time off our other tests. Thanks!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great! One gotcha I ran into, is that it doesn't do anything about Date.now and similar date methods. It's just setTimeout and setInterval.

);
});

test('it should be hidden initially', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be hidden initially? I think it should behave the same as setting hide to true, and should apply the delay. If the consumer really doesn't want it visible, they should just not render it at all.

Copy link
Member Author

@sorenlouv sorenlouv Feb 16, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should behave the same as setting hide to true, and should apply the delay

I think if the initial value is hide=true there is no reason to display it. The whole point of this component is to avoid "UI glitching". If an element is hidden from the start, it won't need the delay.
I think this is very different from a component changing visibility from visible to hidden (which is where we want the delay).

It would also add an extra burden on the consumer. As it is now I can render the global loading spinner with a prop from redux (isLoading is a reduced value combining all loading states in the entire app):

export default ({ isLoading }) => {
  return (
    <EuiDelayHide
      hide={!isLoading}
      render={<EuiProgress size="xs" position="fixed" />}
    />
  );
};

If EuiDelayHide was to always render children on init I would have to keep a flag, to avoid rendering the loading spinner initially:

let isInitialLoad = true;
export default ({ isLoading }) => {
  if (!isLoading && isInitialLoad) {
    return null;
  }
  isInitialLoad = false;

  return (
    <EuiDelayHide
      hide={!isLoading}
      render={<EuiProgress size="xs" position="fixed" />}
    />
  );
};

I think we should wait a little with this but if it becomes a requested feature we can add a flag, eg leading delay (liked lodash.debounce has it).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should wait a little with this but if it becomes a request feature we can add a flag, eg leading delay (liked lodash.debounce has it).

Sounds good!

<EuiDelayHide
hide={this.state.hide}
minimumDuration={this.state.minimumDuration}
render={() => <div>Hello world</div>}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use an instance of <EuiLoadingSpinner size="m"/> instead of the div, just to make the use case a little more realistic?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea.

@cjcenizal
Copy link
Contributor

Oh I also got a console error somehow. Looks like it's coming from setStateDelayed.

image

@sorenlouv
Copy link
Member Author

Oh I also got a console error somehow. Looks like it's coming from setStateDelayed.

Ouch, that looks nasty. Good catch. Can you reproduce it?
There are only two setState calls - one in componentWillReceiveProps and the other in setStateDelayed. It could happen that the component gets destroyed (eg. by navigating away) and then the delayed setState call is fired. I think if I cancel the timeout in componentWillUnmount it should fix it.

Copy link
Member Author

@sorenlouv sorenlouv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for all the feedback!


render() {
return (
<div>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

<EuiDelayHide
hide={this.state.hide}
minimumDuration={this.state.minimumDuration}
render={() => <div>Hello world</div>}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea.

],
text: (
<p>
<EuiCode>DelayHide</EuiCode> is a component for conditionally toggling
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

};

this.lastRenderedTime = this.props.hide ? 0 : Date.now();
this.shouldRender = false;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with that. I'm not 100% sure how you want it to be - can you show it with code? :)

clearTimeout(this.timeout);

const visibleDuration = Date.now() - this.lastRenderedTime;
const timeRemaining = minimumDuration - visibleDuration;
Copy link
Member Author

@sorenlouv sorenlouv Feb 16, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand you correctly, I think the current implementation is correct.

In our case there might be several loading events with a few ms between them. If each takes 500ms, and they have 10ms between them, we should show the loading spinner for 1520ms (500ms + 10ms + 500ms + 10ms + 500ms) and then hide it immediately after the last loading event finishes.

If we reset the timer between each event the loading spinner will be displayed for 2020ms (500ms + 10ms + 500ms + 10 + 1000ms).

};

shouldComponentUpdate() {
return this.shouldRender;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No - will remove it again.
I was of the impression that it would avoid re-rendering the children but it happens anyway so ¯_(ツ)_/¯

describe('when EuiDelayHide is visible initially and is changed to hidden', () => {
let wrapper;
beforeEach(() => {
jest.useFakeTimers();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great! One gotcha I ran into, is that it doesn't do anything about Date.now and similar date methods. It's just setTimeout and setInterval.

);
});

test('it should be hidden initially', async () => {
Copy link
Member Author

@sorenlouv sorenlouv Feb 16, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should behave the same as setting hide to true, and should apply the delay

I think if the initial value is hide=true there is no reason to display it. The whole point of this component is to avoid "UI glitching". If an element is hidden from the start, it won't need the delay.
I think this is very different from a component changing visibility from visible to hidden (which is where we want the delay).

It would also add an extra burden on the consumer. As it is now I can render the global loading spinner with a prop from redux (isLoading is a reduced value combining all loading states in the entire app):

export default ({ isLoading }) => {
  return (
    <EuiDelayHide
      hide={!isLoading}
      render={<EuiProgress size="xs" position="fixed" />}
    />
  );
};

If EuiDelayHide was to always render children on init I would have to keep a flag, to avoid rendering the loading spinner initially:

let isInitialLoad = true;
export default ({ isLoading }) => {
  if (!isLoading && isInitialLoad) {
    return null;
  }
  isInitialLoad = false;

  return (
    <EuiDelayHide
      hide={!isLoading}
      render={<EuiProgress size="xs" position="fixed" />}
    />
  );
};

I think we should wait a little with this but if it becomes a requested feature we can add a flag, eg leading delay (liked lodash.debounce has it).

Copy link
Contributor

@cjcenizal cjcenizal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thanks for doing this @sqren !

@sorenlouv sorenlouv force-pushed the add-delay-hide-component branch from 283f814 to 67b2a23 Compare February 16, 2018 01:58
@sorenlouv
Copy link
Member Author

sorenlouv commented Feb 16, 2018

@cjcenizal I don't have write access. Will you merge?

@epixa
Copy link

epixa commented Feb 16, 2018

@sqren I just double checked and all Elastic employees should have write access. I just gave you admin access though, just to be sure.

@sorenlouv sorenlouv merged commit f19218f into elastic:master Feb 16, 2018
@sorenlouv sorenlouv deleted the add-delay-hide-component branch February 16, 2018 15:18
@sorenlouv
Copy link
Member Author

sorenlouv commented Feb 16, 2018

@cjcenizal What is the process for bumping eui version? Should I do that? And what about publishing?

@epixa
Copy link

epixa commented Feb 16, 2018

@sqren Sorry about that. It looks like the master branch access was locked down to only administrators for some reason. I removed that restriction so anyone with write access can contribute.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants